ChatGPTのストリーミング(SSE)APIを試してみた(Go実装)
はじめに
ChatGPTはOpenAI社によって開発され、APIはOpenAI APIで利用可能です。基礎的な使い方は以下が参考になります。
chat.openai.comでは、入力中であることが分かるように、タイピングインジケータが表示されます。APIでもこの体験ができないか調べてみたところCreate chat completionエンドポイントでは、streamオプションがあったので試してみました。
最終的にはGoでハンドリングするコードを書いたですが、少しはまったため記事にすることにしました。
追記: 他にもいくつか方法があり、net/httpでも書くことが可能です。詳細は以下を参照ください。
前提
- OpenAI APIに登録済み
- APIキー作成済み
- サンプルはOrganization IDを指定しないので請求先が異なる場合指定してリクエストしてください
- /v1/chat/completionsにフォーカスした内容です。他のエンドポイントでサポートされているかはAPI referenceを参照してください
curlで試してみる
APIのBodyメッセージにstream=trueを渡すと、SSE(Server-Sent Event)でメッセージがストリーミングで配信されます。
指定可能なmodelは、Model endpoint compatibilityの/v1/chat/completions
を参考にしてください。
export OPENAI_API_KEY=<OpenAPIのAPIキー> curl https://api.openai.com/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $OPENAI_API_KEY" \ -d '{ "model": "gpt-3.5-turbo", "stream": true, "messages": [{"role": "user", "content": "Server-Sent Eventについて教えて"}] }'
以下のように細かい単位でレスポンスがストリーミング配信されます。
data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"role":"assistant"},"index":0,"finish_reason":null}]} data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"\n\n"},"index":0,"finish_reason":null}]} data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"Server"},"index":0,"finish_reason":null}]} data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"-S"},"index":0,"finish_reason":null}]} data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"ent"},"index":0,"finish_reason":null}]} data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":" Event"},"index":0,"finish_reason":null}]} data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"("},"index":0,"finish_reason":null}]} data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"S"},"index":0,"finish_reason":null}]} data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"SE"},"index":0,"finish_reason":null}]} data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":")"},"index":0,"finish_reason":null}]} data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"は"},"index":0,"finish_reason":null}]} data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"、"},"index":0,"finish_reason":null}]} data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"Web"},"index":0,"finish_reason":null}]} data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"ブ"},"index":0,"finish_reason":null}]} data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"ラ"},"index":0,"finish_reason":null}]} data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"ウ"},"index":0,"finish_reason":null}]} data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"ザ"},"index":0,"finish_reason":null}]} data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"が"},"index":0,"finish_reason":null}]} data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"サ"},"index":0,"finish_reason":null}]} data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"ーバ"},"index":0,"finish_reason":null}]} data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"ー"},"index":0,"finish_reason":null}]} (中略) data: {"id":"chatcmpl-6wbldvgCTzIsGk8fyvmBzV8cHSjlg","object":"chat.completion.chunk","created":1679426221,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"提"},"index":0,"finish_reason":null}]} data: {"id":"chatcmpl-6wbldvgCTzIsGk8fyvmBzV8cHSjlg","object":"chat.completion.chunk","created":1679426221,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"供"},"index":0,"finish_reason":null}]} data: {"id":"chatcmpl-6wbldvgCTzIsGk8fyvmBzV8cHSjlg","object":"chat.completion.chunk","created":1679426221,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"します"},"index":0,"finish_reason":null}]} data: {"id":"chatcmpl-6wbldvgCTzIsGk8fyvmBzV8cHSjlg","object":"chat.completion.chunk","created":1679426221,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"。"},"index":0,"finish_reason":null}]} data: {"id":"chatcmpl-6wbldvgCTzIsGk8fyvmBzV8cHSjlg","object":"chat.completion.chunk","created":1679426221,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{},"index":0,"finish_reason":"stop"}]} data: [DONE]
JSONの構造を詳しくみてみます。初回はrole=assistantが返却されます。
{ "id": "chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB", "object": "chat.completion.chunk", "created": 1679424361, "model": "gpt-3.5-turbo-0301", "choices": [ { "delta": { "role": "assistant" }, "index": 0, "finish_reason": null } ] }
APIリクエストとレスポンスにある、roleの説明は以下の通りです。
role | 説明 |
---|---|
system | システムメッセージに、役割やシナリオを伝え際に利用。必ずしも必要ではないと思っています。 |
user | 実際のユーザーがモデルに問い合わせる質問や要求 |
assistant | モデルが生成した回答 |
{ "id": "chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB", "object": "chat.completion.chunk", "created": 1679424361, "model": "gpt-3.5-turbo-0301", "choices": [ { "delta": { "content": "\n\n" }, "index": 0, "finish_reason": null } ] }
最後には"[DONE]"が返却されます。JSON文字列ではないので注意です。
Goで試してみる
今回はgithub.com/r3labs/sseを利用したのですが、Feature Request: Support non-GET method and request body in the sse clientにある通り、非GETメソッド時にRequest Bodyが指定できないようです。本Issueでパッチ導入済みのforkgithub.com/munisystem/sse(revision: b0476d1)を利用します。(Issue作成者様本当にありがとうございます。。)
Go Modulesはforkを利用する場合、go.modのreplaceを利用して、相対パスで参照させます。
ghq get github.com/munisystem/sse
module github.com/shuntaka9576/sse-sample go 1.19 require github.com/r3labs/sse/v2 v2.10.0 require ( golang.org/x/net v0.0.0-20191116160921-f9c825593386 // indirect gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect ) replace github.com/r3labs/sse/v2 => ../../../github.com/munisystem/sse
package main import ( "bytes" "encoding/json" "fmt" "net/http" "os" "github.com/r3labs/sse/v2" ) type Choice struct { Delta struct { Content string `json:"content"` } `json:"delta"` Index int `json:"index"` FinishReason interface{} `json:"finish_reason"` } type JSONData struct { ID string `json:"id"` Object string `json:"object"` Created int64 `json:"created"` Model string `json:"model"` Choices []Choice `json:"choices"` } const APIKey = "<OpenAPIのAPIキー>" const APIEndpoint = "https://api.openai.com/v1/chat/completions" type Message struct { Role string `json:"role"` Content string `json:"content"` } type requestBody struct { Model string `json:"model"` Stream bool `json:"stream"` Messages []Message `json:"messages"` } type customTransport struct { http.RoundTripper } func main() { client := &http.Client{ Transport: &customTransport{ RoundTripper: http.DefaultTransport, }, } messages := []Message{ {Role: "user", Content: "Server-Sent Eventについて教えて"}, } body := requestBody{ Messages: messages, Model: "gpt-3.5-turbo", Stream: true, } jsonData, err := json.Marshal(body) if err != nil { fmt.Println("Error marshaling request body:", err) os.Exit(1) } sseClient := sse.NewClient(APIEndpoint) sseClient.Connection = client sseClient.Method = "POST" sseClient.Body = bytes.NewBuffer([]byte(jsonData)) sseClient.SubscribeRaw(func(msg *sse.Event) { var jsonData JSONData err := json.Unmarshal([]byte(msg.Data), &jsonData) if err != nil { fmt.Println(err) return } fmt.Printf("%s", jsonData.Choices[0].Delta.Content) }) } func (t *customTransport) RoundTrip(req *http.Request) (*http.Response, error) { req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", APIKey)) req.Header.Set("Content-Type", "application/json") resp, err := t.RoundTripper.RoundTrip(req) if err != nil { return nil, err } return resp, err }
実行すると以下の通りです。
最後の方クラッシュしていますが、ストリーミングの最後は[DONE]
文字列がmsg.Dataとして返却されるため、JSONへデシリアライズで失敗しています。文字列判定してからデシリアライズするのが良いと思います。
またsseのイベントをGoのチャンネルに送信するオプションもあるので、ソースコードの見通し良くする際に使えると思います。
さいごに
Goの実装例はより良い方法を知っている方いましたらご教授頂けますと幸いです。他の言語だともっとすんなりいくと思います。